OS (26) 분산 시스템(Distributed Systems): 실패를 다루는 기술
분산 시스템(Distributed Systems): 실패를 다루는 기술
안녕하세요! 오늘은 운영체제 분야에서 가장 흥미로우면서도 복잡한 주제인 분산 시스템(Distributed Systems) 에 대해 깊이 있게 다뤄보려고 합니다.
우리가 매일 사용하는 웹 브라우저, 구글, 페이스북 같은 서비스들은 단일 컴퓨터에서 돌아가는 것이 아닙니다. 지구 어딘가에 있는 수천 대의 기계들이 협력하여 서비스를 제공합니다. 이것이 바로 분산 시스템의 본질입니다. 하지만 분산 시스템 개발은 결코 쉽지 않습니다. 어떻게 하면 종종 고장 나는 부품들을 가지고 영원히 멈추지 않는 시스템을 만들 수 있을까? 이것이 우리가 오늘 해결해야 할 핵심 질문입니다.
이 글은 OSTEP(Operating Systems: Three Easy Pieces)의 50장 내용을 바탕으로 작성되었으며, 분산 시스템의 기본 개념부터 통신, RPC, 그리고 시스템 설계 철학까지 상세하게 정리했습니다.
1. 분산 시스템의 핵심: 실패(Failure)
분산 시스템을 공부할 때 가장 먼저 받아들여야 할 사실은 시스템은 반드시 실패한다 는 것입니다. 우리는 완벽한 시스템을 만드는 법을 모릅니다. 기계는 고장 나고, 디스크는 깨지고, 네트워크 케이블은 뽑히며, 소프트웨어는 버그를 일으킵니다.
하지만 사용자 입장에서는 웹 서비스가 절대 중지하지 않는 것처럼 보여야 합니다. 즉, 분산 시스템의 핵심 사안은 실패와 고장의 극복 입니다. 개별 구성 요소는 자주 고장 나지만, 전체 시스템은 마치 고장이 없는 것처럼 보이게 만드는 것, 이것이 분산 시스템의 아름다움이자 가치입니다.
물론 성능(Performance)도 중요합니다. 네트워크를 통해 메시지를 주고받는 것은 느리기 때문에, 우리는 전송 메시지 수를 줄이고 지연 시간(latency)을 낮추며 대역폭(bandwidth)을 높이는 효율적인 설계를 고민해야 합니다.
2. 통신의 기본: 신뢰할 수 없는 세상
분산 시스템에서 통신은 근본적으로 신뢰할 수 없다(Unreliable) 고 가정해야 합니다.
왜 패킷은 사라지는가?
패킷 손실이나 손상에는 여러 이유가 있습니다.
- 비트 오류: 전송 중에 전기적인 문제로 비트가 반전될 수 있습니다.
- 물리적 단절: 케이블이 끊어지거나 기계가 멈출 수 있습니다.
- 버퍼 오버플로우 (가장 흔한 원인): 라우터나 스위치, 혹은 종단 호스트(End host)에는 패킷을 처리하기 전 잠시 담아두는 메모리(버퍼)가 있습니다. 만약 처리 속도보다 패킷이 들어오는 속도가 빠르면 버퍼가 가득 차게 되고, 그 이후 들어오는 패킷은 그냥 버려집니다(Drop).
이처럼 패킷 손실은 네트워킹의 근본적인 문제입니다. 그렇다면 우리는 이에 어떻게 대처해야 할까요?
3. 신뢰할 수 없는 통신 계층 (Unreliable Layer)
가장 간단한 방법은 아무런 조치도 취하지 않는 것입니다. 이를 보여주는 대표적인 예가 UDP(User Datagram Protocol) 입니다.
UDP는 패킷을 보냅니다. 하지만 그 패킷이 도착했는지, 중간에 사라졌는지 확인하지 않습니다. 발신자는 손실에 대해 전혀 알 수 없습니다. (물론 체크섬을 통해 데이터 손상은 검출할 수 있지만, 손실 자체를 막아주진 않습니다.)
아래는 UDP를 이용한 간단한 클라이언트/서버 코드 예제입니다. 이 코드는 메시지를 보내고 받지만, 네트워크 상황에 따라 언제든 실패할 수 있습니다.
UDP 클라이언트 코드 예시 (개념적)
// 클라이언트
int main(int argc, char *argv[]) {
struct sockaddr_in addr, addr2;
char message[BUFFER_SIZE];
sprintf(message, "hello world");
// 주소 설정 및 소켓 생성
int rc = UDP_FillSockAddr(&addr, "machine.cs.wisc.edu", 10000);
int sd = UDP_Open(20000); // 소켓 열기
// 메시지 전송 (도착 보장 없음)
rc = UDP_Write(sd, &addr, message, BUFFER_SIZE);
if (rc > 0) {
// 서버의 응답 대기
int rc = UDP_Read(sd, &addr2, buffer, BUFFER_SIZE);
}
return 0;
}
많은 응용 프로그램은 이런 불안정함을 원하지 않습니다. 그래서 우리는 이 신뢰할 수 없는 계층 위에 신뢰할 수 있는 통신 계층 을 쌓아 올려야 합니다.
4. 신뢰할 수 있는 통신 계층 만들기 (Reliable Layer)
신뢰할 수 없는 네트워크 위에서 신뢰성을 확보하기 위해 우리는 몇 가지 핵심 기술(Mechanism)을 도입해야 합니다. TCP와 같은 프로토콜이 내부적으로 사용하는 기술들이기도 합니다.
기술 1: 확인(Acknowledgement, ACK)
발신자가 메시지를 보냈을 때, 수신자가 이를 잘 받았는지 어떻게 알 수 있을까요? 수신자는 메시지를 받으면 잘 받았음"이라는 짧은 메시지(ACK) 를 발신자에게 되돌려 보냅니다.
- 발신자: 메시지 전송 -> (대기) -> ACK 수신 -> 성공 확신
기술 2: 타임아웃(Timeout)과 재시도(Retry)
만약 메시지가 가다가 사라지거나, 메시지는 잘 갔는데 돌아오는 ACK가 사라지면 어떻게 될까요? 발신자는 하염없이 기다리게 됩니다. 이를 해결하기 위해 타임아웃 을 설정합니다.
- 발신자는 메시지를 보내면서 타이머 를 켭니다.
- 동시에 혹시 모를 재전송을 위해 메시지의 사본 을 보관합니다.
- 일정 시간 내에 ACK가 오지 않으면, 메시지가 손실되었다고 판단하고 재전송 합니다.
이때 타임아웃 값의 설정 이 매우 중요합니다.
- 너무 짧으면? 메시지가 아직 가는 중인데 재전송을 해서 네트워크 낭비가 발생합니다.
- 너무 길면? 패킷이 손실되었는데도 한참 뒤에야 재전송하므로 성능이 떨어집니다.
- 현대적인 시스템은 네트워크 상황에 따라 타임아웃 값을 동적으로 조절하거나(Adaptive), 지수적 백오프(Exponential Back-off) 등을 사용해 오버헤드를 줄입니다.
기술 3: 순서 카운터(Sequence Counter)와 중복 방지
타임아웃/재시도 방식에는 치명적인 문제가 하나 있습니다. 메시지는 잘 도착했는데, ACK만 사라진 경우 를 생각해 봅시다.
- 발신자: 메시지 전송 -> (성공) -> 수신자: 메시지 처리 및 ACK 전송 -> (ACK 손실)
- 발신자: 타임아웃 발생! -> 메시지 재전송
- 수신자: 똑같은 메시지를 또 받음!
파일을 다운로드하거나 결제 요청을 처리할 때 중복 실행은 치명적입니다. 따라서 수신자는 이것이 새로운 메시지인지, 아까 받은 메시지의 재전송인지 구분해야 합니다.
이를 위해 순서 카운터(Sequence Counter) 를 사용합니다.
- 발신자와 수신자는 1부터 시작하는 카운터를 공유합니다.
- 발신자는 메시지에
ID=1을 붙여 보냅니다. - 수신자는
ID=1을 받고 처리한 뒤, 다음엔ID=2가 올 것을 기대합니다. - 만약 발신자가 ACK를 못 받아
ID=1을 재전송하면? 수신자는 "난 이미 1번을 처리했고 2번을 기다리는 중이야"라고 판단하여, 메시지 내용은 버리고 ACK만 다시 보내줍니다.
이 세 가지 기술(ACK, 타임아웃/재시도, 순서 번호)을 조합하면, 신뢰할 수 없는 네트워크 위에서도 신뢰성 있는 통신(TCP 등)을 구현할 수 있습니다.
5. 통신 추상화 (Communication Abstraction)
기본적인 통신 계층이 마련되었다면, 이제 개발자가 분산 시스템을 더 쉽게 만들 수 있도록 추상화된 개념이 필요합니다. 역사적으로 두 가지 주요 접근 방식이 있었습니다.
접근 1: 분산 공유 메모리 (Distributed Shared Memory, DSM)
운영체제 연구자들은 "분산 시스템을 마치 멀티스레드 프로그래밍처럼 만들면 어떨까?"라고 생각했습니다.
- 개념: 서로 다른 기계에 있는 프로세스들이 하나의 거대한 가상 주소 공간을 공유합니다.
- 동작: 내 기계에 없는 데이터에 접근하면 페이지 폴트(Page fault) 가 발생하고, OS가 네트워크를 통해 다른 기계에서 해당 페이지를 가져옵니다.
- 결과: 실패했습니다.
- 이유 1 (실패 처리): 다른 기계가 고장 나면 내 주소 공간의 일부가 갑자기 사라집니다. 메모리 참조가 실패한다는 것은 프로그래머가 처리하기 매우 어렵습니다.
- 이유 2 (성능): 프로그래머는 메모리 접근이 빠르다고 가정하고 코드를 짭니다. 하지만 DSM에서는 어떤 메모리 접근은 네트워크를 타야 하므로 엄청나게 느립니다. 이를 피하려면 프로그래머가 데이터 위치를 신경 써야 하는데, 이러면 DSM을 쓰는 의미가 없어집니다.
접근 2: 원격 프로시저 호출 (Remote Procedure Call, RPC)
이 방식은 프로그래밍 언어(PL) 관점에서 접근했습니다.
- 목표: 원격 기계에 있는 함수를 마치 내 로컬에 있는 함수처럼 호출하게 하자.
- 현재 분산 시스템의 지배적인 표준(Dominant abstraction)입니다.
6. RPC(Remote Procedure Call)의 내부
RPC는 클라이언트가 서버의 함수를 호출하고 결과를 받는 과정을 단순화합니다. 이를 위해 스텁 생성기(Stub Generator) 와 런타임 라이브러리(Runtime Library) 가 필요합니다.
6.1 스텁 생성기 (Stub Generator)
스텁 생성기는 인터페이스 정의를 읽어서 클라이언트와 서버 양쪽에 필요한 코드를 자동으로 만들어줍니다. 이를 통해 개발자는 복잡한 네트워크 코드를 짤 필요가 없습니다.
클라이언트 스텁(Client Stub)이 하는 일:
- 메시지 버퍼 생성: 데이터를 담을 패킷을 준비합니다.
- 마샬링(Marshaling) / 직렬화(Serialization): 함수 이름과 인자(argument)들을 패킷에 차곡차곡 담습니다.
- 전송: RPC 런타임을 통해 서버로 메시지를 보냅니다.
- 언마샬링(Unmarshaling): 서버로부터 응답이 오면 결과를 해석해서 클라이언트 코드에 반환합니다.
서버 스텁(Server Stub)이 하는 일:
- 언마샬링: 도착한 메시지를 풀어서 어떤 함수를 호출할지, 인자는 무엇인지 알아냅니다.
- 함수 호출: 실제 서버 쪽의 함수를 실행합니다.
- 결과 마샬링: 실행 결과를 다시 패킷으로 포장하여 클라이언트에게 보냅니다.
6.2 RPC의 기술적 난관들
RPC는 겉보기엔 단순하지만 내부적으로 해결해야 할 복잡한 문제들이 있습니다.
문제 1: 포인터와 복잡한 자료구조 로컬 함수 호출에서는 포인터(메모리 주소)를 전달하는 것이 흔합니다. 하지만 RPC에서는?
- 내 컴퓨터의
0x1000번지와 서버 컴퓨터의0x1000번지는 전혀 다릅니다. - 따라서 단순히 포인터를 보내면 안 되고, 포인터가 가리키는 실제 데이터 를 직렬화해서 보내야 합니다. 리스트나 트리 같은 복잡한 구조라면 더 까다롭겠죠.
문제 2: 바이트 순서 (Byte Ordering/Endianness) 어떤 기계는 빅 엔디안(Big Endian) 을 쓰고, 어떤 기계는 리틀 엔디안(Little Endian) 을 씁니다.
- RPC 패키지는 XDR(External Data Representation) 같은 표준 형식을 정해서, 전송할 때는 표준 형식으로 변환하고 받을 때는 자신의 기계에 맞게 다시 변환하는 작업을 수행해야 합니다.
문제 3: 성능과 전송 계층 (Transport Layer) RPC는 TCP 위에서 구현해야 할까요, UDP 위에서 구현해야 할까요?
- "안전하게 하려면 TCP지!"라고 생각할 수 있습니다.
- 하지만 TCP는 이미 자체적인 ACK와 핸드셰이크 과정이 있어 느립니다. RPC 자체도 요청-응답(Request-Response) 구조라 일종의 ACK 역할을 하는데, TCP의 ACK까지 더해지면 메시지가 너무 많아집니다.
- 그래서 고성능 RPC 시스템은 종종 UDP 위에 자체적인 신뢰성 계층(타임아웃/재시도)을 구현 하여 사용합니다.
문제 4: 병행성 (Concurrency) 서버가 한 번에 하나의 요청만 처리한다면(Iterative Server), I/O 작업 중에 다른 요청들은 하염없이 기다려야 합니다.
- 따라서 대부분의 RPC 서버는 스레드 풀(Thread Pool) 을 사용하여 동시에 여러 요청을 처리하도록 구성됩니다.
7. 단-대-단 논쟁 (The End-to-End Argument)
분산 시스템 설계에서 가장 중요한 철학 중 하나인 End-to-End Argument(단-대-단 논쟁) 에 대해 이야기해 봅시다. Saltzer 등이 제안한 이 개념은 시스템의 기능을 어디에 구현해야 하는지에 대한 가이드라인입니다.
예시: 파일 전송
A 컴퓨터에서 B 컴퓨터로 파일을 보낸다고 가정합시다. 데이터가 깨지지 않고 완벽하게 전송되기를 원합니다. "네트워크 계층(하위 계층)에서 100% 신뢰성을 보장해주면 되는 거 아냐?"라고 생각할 수 있습니다.
하지만 하위 계층이 완벽해도 문제는 생깁니다.
- 발신자의 메모리에서 데이터가 이미 깨져 있었다면?
- 수신자가 데이터를 받아서 디스크에 쓸 때 오류가 났다면?
네트워크가 아무리 완벽하게 데이터를 날라줘도, 파일 전송이라는 최상위 응용 프로그램의 목표(End-to-End Goal) 는 달성되지 않을 수 있습니다. 결국 신뢰성 있는 파일 전송을 보장하려면, 애플리케이션 계층(가장 끝단) 에서 전송 완료 후 파일의 체크섬(Checksum)을 비교하는 등의 검증을 반드시 수행해야 합니다.
교훈
- 하위 계층(네트워크 등)이 제공하는 기능에만 전적으로 의존해서는 안 됩니다. 중요한 기능은 반드시 최상위 레벨(End-to-End)에서 구현하고 검증해야 합니다.
- 그렇다고 하위 계층의 기능을 무시하라는 것은 아닙니다. 하위 계층에서 재전송 등을 해주면 상위 계층의 부담을 줄여주어 성능 최적화 에 도움이 됩니다.
8. 요약 및 결론
분산 시스템은 전 세계를 연결하는 현대 컴퓨팅의 기반입니다. 하지만 "실패(Failure)"라는 피할 수 없는 현실 위에서 동작합니다.
- 통신은 불안정합니다: 패킷은 언제든 손실될 수 있습니다.
- 신뢰성 확보: 이를 극복하기 위해 ACK, 타임아웃, 재시도, 순서 번호 같은 기술을 사용합니다.
- RPC: 분산 시스템을 쉽게 개발하기 위해 원격 호출을 로컬 호출처럼 보이게 하는 RPC 추상화를 주로 사용합니다. 스텁 생성기와 런타임이 복잡한 마샬링, 네이밍, 프로토콜 처리를 대신해줍니다.
- End-to-End Argument: 하위 계층의 신뢰성만 믿지 말고, 애플리케이션 레벨에서 최종적인 무결성을 검증해야 합니다.
이 챕터를 통해 여러분은 구글이나 페이스북 같은 거대한 시스템이 어떤 원리로, 그리고 어떤 고민들 속에서 동작하는지 이해하는 첫걸음을 떼셨습니다. 분산 시스템은 어렵지만, 불완전한 부품들로 완벽에 가까운 서비스를 만들어내는 매력적인 분야입니다.
